iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 38 篇

Day 23(下)|LLM Observability:一條 Trace 要能還原工作流

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說:schema 只是紙上談兵,真正決定一條 trace 能不能救你的,是 failure path 有沒有留下 span、trace 能不能跟 logs 對上、敏感資料有沒有在正確的地方被擋下來——這些都得靠實作與驗收清單來檢查。

上篇(上)把 trace 的資料模型定下來:先釐清 trace 跟 metrics、logs 的分工,再拆出 trace_id/span_id/SpanKind 這些底層概念,接著設計 LLM workflow 該有的 span tree、resource/attribute/event 該放哪一層,並把 outcome 拆成 technical / workflow / quality / safety / task 五個獨立欄位,最後整理成一份可測試的 AI Trace Contract v1。本篇(下)接著把這套 schema 實作出來,並提供完整的 DIY 步驟與驗收清單。

⑧ 程式示範:在 FastAPI workflow 建出正確的父子關係

下面的範例刻意不用真實 LLM 或真實文件。

目的不是模擬 production 流量。

目的是讓讀者先看清楚 span 的邊界、低敏感度 metadata 與 failure path。

這背後有一個務實理由:第一次寫 LLM trace instrumentation 就同時處理「span 邊界對不對」和「真實 API 是否成功、要不要重試」兩個問題,很容易分不清 bug 是 instrumentation 寫錯,還是外部依賴在鬧脾氣。先用零外部依賴的 mock 把 span tree 結構確認到位,之後換成真實模型時,換血過程只是把 mock 回傳值換成真實 API 呼叫,span 邊界不用重新設計。

先建立 app/telemetry.py。

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor


def configure_tracing() -> None:
    resource = Resource.create(
        {
            "service.name": "ai-policy-api",
            "service.version": "dev",
            "deployment.environment.name": "local",
        }
    )
    provider = TracerProvider(resource=resource)
    provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
    trace.set_tracer_provider(provider)


tracer = trace.get_tracer("ai-policy-api.workflow")

ConsoleSpanExporter 只適合 local learning。

它把 span 印到 terminal,方便你先確認樹長對了。

真的送往後端時,改用 OTLP exporter 和 Collector;Day 32 會把這條路接到 Langfuse 與 OpenTelemetry Collector。

再建立 app/workflow.py。

from dataclasses import dataclass
from hashlib import sha256

from .telemetry import tracer


@dataclass
class AskResult:
    answer: str
    task_status: str
    quality_status: str


def stable_hash(value: str) -> str:
    return sha256(value.encode("utf-8")).hexdigest()[:16]


def retrieve(question: str) -> list[dict[str, str]]:
    with tracer.start_as_current_span("retrieval.search") as span:
        span.set_attribute("retrieval.index_version", "handbook-demo-v1")
        span.set_attribute("retrieval.query_hash", stable_hash(question))
        documents = [
            {
                "document_id": "remote-work-policy",
                "text": "遠端工作需依公司政策與主管核准流程辦理。",
            }
        ]
        span.set_attribute("retrieval.result_count", len(documents))
        span.set_attribute("workflow.outcome", "ok")
        return documents


def call_model(question: str, documents: list[dict[str, str]]) -> str:
    with tracer.start_as_current_span("model.chat") as span:
        span.set_attribute("model.provider", "mock")
        span.set_attribute("model.name", "policy-demo")
        span.set_attribute("model.route", "primary")
        span.set_attribute("prompt.version", "policy-qa-v1")
        span.set_attribute("input_token_count", 42)
        span.set_attribute("output_token_count", 18)
        span.set_attribute("workflow.outcome", "ok")
        return "依遠端工作政策,請依主管核准流程辦理。"


def validate(answer: str, documents: list[dict[str, str]]) -> str:
    with tracer.start_as_current_span("answer.validate") as span:
        grounded = "主管核准" in answer and bool(documents)
        span.set_attribute("quality.status", "pass" if grounded else "fail")
        span.set_attribute("safety.status", "pass")
        span.set_attribute("citation.valid", grounded)
        span.set_attribute("workflow.outcome", "ok" if grounded else "failed")
        return "pass" if grounded else "fail"


def answer_question(request_id: str, question: str) -> AskResult:
    with tracer.start_as_current_span("POST /ask") as root:
        root.set_attribute("request.id", request_id)
        root.set_attribute("workflow.name", "policy-qa")
        root.set_attribute("workflow.version", "v1")
        root.set_attribute("technical.status", "ok")

        with tracer.start_as_current_span("prompt.build") as prompt_span:
            prompt_span.set_attribute("prompt.version", "policy-qa-v1")
            prompt_span.set_attribute("question_hash", stable_hash(question))
            prompt_span.set_attribute("workflow.outcome", "ok")

        documents = retrieve(question)
        answer = call_model(question, documents)
        quality_status = validate(answer, documents)

        task_status = "completed" if quality_status == "pass" else "failed"
        root.set_attribute("workflow.status", "completed")
        root.set_attribute("quality.status", quality_status)
        root.set_attribute("task.status", task_status)
        return AskResult(answer, task_status, quality_status)

這段程式刻意只記錄 question_hash。

它可以協助你確認同一問題是否在不同版本下出現,而不把問題原文散落到每個 trace backend。

這不是萬靈丹。

可猜測的短字串仍可能被 brute-force;敏感度更高時要改成受控 token、直接省略,或在 Collector 端先清理。

之所以只取雜湊值的前 16 個字元而不是完整的 64 個字元,是刻意在「足夠用來比對是否為同一個問題」與「不要讓 trace attribute 動輒塞進一串過長的雜湊字串」之間取捨——16 個十六進位字元已經提供 64 位元的雜湊空間,對「同一份 handbook 問題集裡是否重複出現同一個 question」這種比對用途綽綽有餘,不需要為了追求理論上更低的碰撞機率,犧牲 trace 的可讀性。這也再次印證第⑤節的態度:schema 設計永遠是在多個目標之間找一個夠用、可解釋的平衡點,而不是無限逼近理論最優解。

建立 app/main.py。

from itertools import count

from fastapi import FastAPI
from pydantic import BaseModel

from .telemetry import configure_tracing
from .workflow import answer_question


configure_tracing()
app = FastAPI()
request_sequence = count(1)


class AskRequest(BaseModel):
    question: str


@app.post("/ask")
def ask(payload: AskRequest) -> dict[str, str]:
    request_id = f"local-{next(request_sequence)}"
    result = answer_question(request_id, payload.question)
    return {
        "request_id": request_id,
        "answer": result.answer,
        "task_status": result.task_status,
        "quality_status": result.quality_status,
    }

這個 demo 用 process 內 counter 產生 request_id,只適合 local。

多 process、重啟或 production 環境應使用安全的 request identifier,並把它放進 log context 與回應 metadata。

不要把 trace_id 偽裝成授權 token,也不要直接把它當使用者識別碼。

三個檔案為什麼要這樣切

這個 demo 刻意把 tracing 設定、workflow 邏輯、HTTP 入口拆成三個檔案,直接對應第②節「API 與 SDK 分離」的設計原則:telemetry.py 只回答「span 要被送到哪裡」,workflow.py 只回答「這個 workflow 有哪些步驟、每一步該記什麼 attribute」,main.py 只負責把 HTTP 請求接進來、把結果傳出去。

這種切法帶來一個容易被忽略的好處:workflow.py 裡沒有 import 任何 FastAPI 或 exporter 相關的東西,代表這份 workflow 邏輯可以被 CLI script、背景 worker 或未來的 batch job 直接複用,不必跟著 FastAPI 的 request/response 週期綁死——這也是為什麼 answer_question() 接受單純的 request_id: str 與 question: str,而不是 FastAPI 的 Request 物件。

⑨ failure path 也要有 span,不要只留下 exception

成功路徑很好畫。

真正容易讓 trace 失去價值的是失敗路徑。

假設外部 tool 因 policy 被拒絕。

錯誤做法是這樣。

POST /ask = error

這只告訴你根 span 壞了。

更好的結構如下。

POST /ask                         status=OK
├─ retrieval.search                outcome=ok
├─ model.chat                      outcome=ok
├─ tool.policy_check               decision=deny
└─ answer.validate                 safety.status=refused

根 span 是否標成 error,取決於 API 是否真的未完成可預期行為。

「正確拒絕一個不允許的 tool」通常不該被當成 technical error。

反過來,tool 應該被允許卻連線 timeout,才屬於 dependency failure。

可以在 tool wrapper 補上 event 與 error status。

from opentelemetry.trace import Status, StatusCode

from .telemetry import tracer


def execute_tool(tool_name: str) -> dict[str, str]:
    with tracer.start_as_current_span(f"tool.execute.{tool_name}") as span:
        span.set_attribute("tool.name", tool_name)
        try:
            raise TimeoutError("demo upstream timeout")
        except TimeoutError as exc:
            span.record_exception(exc)
            span.set_status(Status(StatusCode.ERROR, "upstream timeout"))
            span.set_attribute("technical.status", "error")
            span.set_attribute("workflow.outcome", "failed")
            raise

不要在 exception message 中塞 API key、完整 request body 或使用者資料。

record_exception() 也不是隱私豁免。

exception 的建構方式本身就應該避免帶進秘密。

實務上一個常見的踩雷方式,是把外部 API 回傳的完整錯誤 body 原封不動塞進 exception message。若下游服務剛好把使用者的原始 request 回顯在錯誤訊息裡(許多 API 的除錯模式會這樣做),你就在無意間把敏感內容透過 exception 一路帶進了 trace 後端。

比較安全的做法是只擷取 exception 的類型與少量結構化欄位(例如 HTTP status code、錯誤代碼),完整除錯內容留在應用程式自己的 log 裡,並套用第⑪節的遮罩規則。

區分「拒絕」與「故障」:呼應 Day 3 的分類法

Day 3 在做故障注入實驗時,已經把「client timeout」與「server 端真的完成了」拆成兩種不同現象;今天的 tool failure 需要做同樣性質、但語意層級更高的分類。一個 tool 呼叫沒有回應預期結果,至少可能是三種原因,各自對應不同的修復動作與告警急迫度。

tool 被 policy 正確拒絕
  → decision=deny,不是 technical error
  → 修法:檢查 policy 規則是否符合預期,通常不用叫醒 on-call

tool 應該被允許,卻連線逾時
  → dependency failure,technical.status=error
  → 修法:查該 tool 的下游依賴,可能要進 Day 24 的服務層 dashboard

tool 回傳格式錯誤,讓後續 parser 掛掉
  → 上游輸出被截斷或格式不符 schema
  → 修法:檢查 model 的 finish_reason 是否為 length,或 schema 是否過期

若把這三種情況全部塞進同一個 technical.status=error,on-call 拿到告警的第一個動作永遠是「打開 trace 慢慢看」,而不是「一眼分辨這是不是我該管的事」。這正是第⑤節分層邏輯在 failure path 上的延伸:失敗路徑同樣需要拆分「失敗的種類」。

把這三種分類具體寫進程式碼,會是這樣:

from opentelemetry.trace import Status, StatusCode

from .telemetry import tracer


def execute_tool_with_policy(tool_name: str, allowed: bool) -> dict[str, str] | None:
    with tracer.start_as_current_span(f"tool.execute.{tool_name}") as span:
        span.set_attribute("tool.name", tool_name)

        if not allowed:
            # 情況一:policy 正確拒絕,不是 technical error
            span.set_attribute("tool.decision", "deny")
            span.set_attribute("technical.status", "ok")
            span.set_attribute("workflow.outcome", "ok")
            return None

        try:
            # 假設這裡是真正呼叫下游 tool 的邏輯
            result = call_downstream_tool(tool_name, timeout=2.0)
        except TimeoutError as exc:
            # 情況二:應該被允許,卻連線逾時 → dependency failure
            span.record_exception(exc)
            span.set_status(Status(StatusCode.ERROR, "downstream timeout"))
            span.set_attribute("technical.status", "error")
            span.set_attribute("workflow.outcome", "failed")
            raise

        if not is_schema_valid(result):
            # 情況三:回傳格式不符,可能是上游輸出被截斷
            span.set_attribute("tool.decision", "malformed_response")
            span.set_attribute("technical.status", "error")
            span.set_attribute("workflow.outcome", "failed")
            return None

        span.set_attribute("tool.decision", "executed")
        span.set_attribute("technical.status", "ok")
        span.set_attribute("workflow.outcome", "ok")
        return result

同一個函式裡,三條分支各自標記了完全不同的 technical.status 與 workflow.outcome 組合。這代表事後在 dashboard 上做彙總統計時,你可以清楚分開「policy 正常運作」與「dependency 真的出問題」這兩種截然不同的訊號——而不必打開每一條 trace 才能分辨。

⑩ Trace context 要能和 logs 對上

只在 trace UI 看得到的證據,事故時很容易斷線。

至少讓 application log 同時帶出 trace_id、span_id 與自己的 request_id。

概念上會是這樣。

{
  "level": "INFO",
  "message": "validator completed",
  "request_id": "local-7",
  "trace_id": "4c1b...",
  "span_id": "e91a...",
  "quality_status": "pass"
}

這層關聯不會自動發生。

需要在 logging pipeline 裡主動把目前 span 的 id 讀出來,寫進每一筆 log record。概念上會是這樣一個 filter。

import logging

from opentelemetry import trace


class TraceContextFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        span_context = trace.get_current_span().get_span_context()
        if span_context.is_valid:
            record.trace_id = format(span_context.trace_id, "032x")
            record.span_id = format(span_context.span_id, "016x")
        else:
            record.trace_id = record.span_id = None
        return True

只要把這個 filter 掛在 root logger 上,任何在 with tracer.start_as_current_span(...) 範圍內寫出的 log,都會自動帶上當下的 trace_id 與 span_id,不需要每個 log call 手動傳參數。

這個機制之所以能無痛運作,關鍵在於 trace.get_current_span() 讀的是 Python 的 contextvars,跟 tracer.start_as_current_span() 寫入 span context 的是同一套底層機制。

所以只要當下這行程式碼確實在某個 span 的 with 區塊內執行,filter() 就一定能讀到正確的值,不需要額外傳遞任何參數穿越整條呼叫鏈。

反過來說,這也解釋了為什麼 contextvars 在 async 程式碼裡需要特別小心:如果你用了某些不會正確傳遞 context 的並行寫法(例如手動開執行緒卻沒有搬運 context),這個 filter 讀到的就會是空的 span context,log 裡的 trace_id 會悄悄變成 None,而且不會有任何錯誤訊息提醒你。

反過來說,如果某個背景任務或 queue consumer 是在 span context 之外才寫 log,這個 filter 只會拿到一個無效的 span context,trace_id 便會是空值。這通常就是下面要談的邊界斷點的第一個訊號——先去檢查那段程式碼是不是漏了把 trace context 從觸發它的 request 傳進來。

請避免把同一個 request id 做成 Prometheus label。

每一個 request 都有新值,會造成高 cardinality。

trace 與 logs 適合保留高辨識度 id;metrics 應保留低 cardinality 的維度,例如 workflow_name、model_route、outcome。

適合 trace / log:
request_id、trace_id、document_id、evaluation.case_id

適合 metric label:
workflow_name、model_route、quality_status、task_status

不適合 metric label:
prompt、user_id、request_id、trace_id、完整 URL query

OpenTelemetry 的 context propagation 是這個關聯的底座。

如果 service A 轉送到 service B 時沒有帶 trace context,兩邊各自看起來都正常,卻無法被拼成一次使用者旅程。

先檢查 HTTP client、queue consumer 與 background job 三種邊界。

它們最容易讓 parent-child 關係斷掉。

background job 的邊界:context 不會自動搭便車

HTTP 呼叫之間傳 traceparent 標頭,多數 framework 的 middleware 已經幫你做好了;但工作一旦被丟進 queue、丟給 background worker,這個自動化就會斷掉,因為 queue message 沒有 HTTP 標頭這種天然的載體。不手動把 context 編碼進 message body、再由 consumer 解回來,consumer 那端產生的 span 就會憑空長出一條全新的 trace,跟原本觸發它的 request 完全失去關聯。

from opentelemetry import trace, context
from opentelemetry.propagate import inject, extract

# producer 端:把目前 context 編碼進 queue message
def enqueue_reindex_job(document_id: str) -> dict:
    carrier: dict[str, str] = {}
    inject(carrier)  # 把目前 span context 寫進 carrier
    return {"document_id": document_id, "trace_context": carrier}


# consumer 端:從 message 還原 context,再開自己的 span
def handle_reindex_job(message: dict) -> None:
    parent_context = extract(message["trace_context"])
    tracer = trace.get_tracer("ai-policy-api.worker")
    with tracer.start_as_current_span(
        "worker.reindex_document", context=parent_context
    ) as span:
        span.set_attribute("document.id", message["document_id"])
        # 實際重新索引邏輯

沒有這段 inject / extract,API service 的 trace 會在把工作丟進 queue 那一刻結束,worker 端卻在幾秒或幾分鐘後憑空冒出一條全新、沒有 parent 的 trace。這種斷點特別危險,因為兩邊看起來都「正常運作」——沒有任何一邊的監控會主動告訴你,這其實是同一件事的兩半。

三種訊號回到 Day 2 那套 Collector 架構裡的位置

Day 2 建立的 Observability Stack,把 Application 與後端存儲用一個 Collector 隔開,讓 Application 只認 OTLP、存儲可以換而不用動程式碼。把 trace context 塞進 log 的這個動作,是同一套架構原則的延伸——延伸的對象從「trace 要送到哪裡」變成「trace 的身分要怎麼跟著 log 一起走」。

Application process
  ├─ trace span  ──OTLP/gRPC──▶  OpenTelemetry Collector ──▶ Tempo
  └─ log record(帶 trace_id/span_id)
         ↓ Grafana Alloy(discovery.docker + loki.source.docker)
       Loki

Grafana
  ├─ 查詢 Tempo:這條 trace 的 span tree 長什麼樣?
  └─ 用同一個 trace_id 查詢 Loki:同一時刻這個 service 還印了什麼?

這張圖裡最容易漏掉的一步,是那個「帶 trace_id/span_id」的箭頭——它不是 Alloy 或 Collector 自動幫你做的,而是 Application 自己要在寫出每筆 log 之前,把當下 span context 的 id 塞進 log record,正是前面 TraceContextFilter 在做的事;Collector 與 Alloy 只負責原封不動轉送,沒有能力事後幫你把孤立的 log 和某條 trace 配對起來。

⑪ 隱私、保留期與 sampling:觀測資料也是資料產品

「先全部收再說」在 LLM trace 特別危險。

prompt 可能含有內部文件、帳號資訊、採購內容、病歷片段,或使用者以為只會被模型處理的私密描述。

response 也可能把這些內容原封不動帶回 telemetry backend。

先建立資料分級,而不是等法遵問題出現才補 regex。

類型 預設處理 trace 可保留的替代資訊
prompt / response 原文 不寫入 長度、版本、分類、受控 hash
文件全文與 chunk 不寫入 document id、index version、result count
email、電話、帳號 不寫入 匿名 cohort 或完全省略
access token、authorization header 絕不寫入 無
model、prompt、index 版本 保留 原值
latency、token count、retry count 保留 原值或 bucket

「hash 就安全」同樣是危險的簡化。

若輸入空間很小,例如部門名稱、短代碼或常見問題,攻擊者可以猜測後比對 hash。

因此 hash 只是一個 debugging trade-off,不是去識別化保證。

需要跨系統關聯時,應由資安與資料治理一起決定 tokenization、salt、存取權限與保留期。

工程團隊常見的誤區,是把這個決定完全留給自己判斷——覺得「這個雜湊應該夠安全」就直接上線。實際上,輸入空間夠不夠小到能被暴力比對、保留期該設多久、哪些角色能存取原始 trace,這些都不是純技術問題,而是牽涉法遵與風險胃納的組織決策,工程團隊能提供的是技術選項與成本,決策權應該留給更適合評估風險的角色。

sampling 也要和目的連動。

成功 request
  → 低比例 sample,保留 latency、version、outcome

technical error / quality fail / safety refusal
  → 較高比例 sample,但仍套用同一份敏感資料規則

特定 regression case
  → 在 staging 或受控測試環境全量保留

不要因為某條 trace 是 error,就自動允許記錄完整 prompt。

事故最混亂的時候,往往也是敏感資料最容易被抄進診斷工具的時候。

把遮罩規則放在 Collector,而不是每個服務各寫一份

前面提到 OpenTelemetry 官方文件把 Collector 定位為集中做 enrichment、sampling 與敏感資訊清理的地方,這值得展開成具體設計。每個服務都自己在程式碼裡寫一套「這個欄位要不要遮罩」的邏輯,很快會遇到兩個問題:規則因不同團隊理解不同而長歪,而且哪天要改規則,得同步改好幾個服務的程式碼並重新部署。

比較穩妥的做法,是讓應用程式端只管誠實記錄資料(同時遵守 schema contract 裡「禁止預設寫入」那一欄),真正的遮罩與過濾規則集中寫在 Collector 的 processor 設定裡。

# otel-collector-config.yaml(節錄)
processors:
  attributes/redact:
    actions:
      - key: authorization_header
        action: delete
      - key: raw.prompt
        action: delete
      - key: raw.response
        action: delete
      - key: user.email
        action: delete

  probabilistic_sampler:
    sampling_percentage: 10   # 成功路徑的抽樣比例,對應第⑪節前面的表格

service:
  pipelines:
    traces:
      processors: [attributes/redact, probabilistic_sampler, batch]

這樣設計的好處是:遮罩規則變成一份集中維護的設定檔,資安或法遵團隊審查時只需要看這份 YAML;即使某個服務不小心多記了不該記的欄位,Collector 這一層還能當最後一道防線把它攔下來,不必等程式碼重新部署才生效。

為什麼是 5%~10%,不是全量也不是 1%

上面那張 sampling 表格的比例不是隨口寫的經驗值。生產環境幻覺偵測的實務整理指出,逐筆檢查每次回應的品質在成本上不可行——不管是跑額外的 judge 模型還是做引文比對,都要花錢、花延遲;但完全不檢查又等於放棄偵測 silent degradation 的唯一機會。業界因此收斂出一個折衷:開發與 regression 測試環境做全量檢查建立品質基準;進了生產環境,改成對 5% 到 10% 的流量做完整品質評估,搭配異常檢測追蹤這個抽樣子集合的行為變化趨勢。

這個比例背後的判斷準則跟第⑤節的 citation-valid ratio 門檻是同一件事:不要用單一樣本的成敗觸發告警,而要看抽樣視窗裡的比例走勢。一次抽到的樣本剛好 quality.status=fail,可能只是隨機噪音;但連續幾個抽樣視窗裡這個比例持續往下掉,那才是值得升級處理的系統性訊號——這也是為什麼 sampling 表格把「technical error / quality fail / safety refusal」的抽樣比例調高,讓這類 outcome 有足夠樣本量分辨「噪音」還是「趨勢」。

單一樣本 quality.status=fail
  → 記錄,但不直接告警
  → 進入下一輪 rolling window 的分母

rolling window(例如過去 500 個被抽樣的 request)
  citation-valid ratio 持續低於門檻
  → 才進入第⑥節提到的 evaluation queue

把這套邏輯放回 trace schema,代表 evaluation.case_id、evaluation.run_id 這類欄位不只是給人工回溯用的線索,也是讓自動化 sampling pipeline 能把「哪些 trace 屬於同一個抽樣視窗」重新拼起來的關鍵。

⑫ 今日 DIY:寫 trace schema contract,並做一次可重跑的 local trace

這一節提供讀者自己建立 Day23/DIY/ 的步驟。

本文不會替你建立、安裝、啟動或驗證該 DIY;請在自己的獨立目錄完成,並依輸出結果調整欄位名稱。

即使暫時不打算真的動手跑,這一節仍值得讀完——它不是操作手冊,而是把前面十一節的抽象概念,逐一對應到可以親眼驗證的具體行為。每個步驟後都會說明它驗證了文章哪句話、結果跟預期不同代表踩到了哪個坑。

1. 建立獨立環境與相依套件

以下命令在你自己的 Day23/DIY/ 目錄執行。

uv init
uv add fastapi uvicorn pydantic \
  opentelemetry-api \
  opentelemetry-sdk

本日先用 console exporter,因此不需要先選 trace backend。

若你已經有 Day 2 的 observability stack,也可以先讓 local console output 當 schema 檢查,再把 exporter 接到既有 Collector。

為什麼先用 console exporter,不直接接 Collector:這是刻意的驗證分層。一開始就接 Day 2 的 OTLP Collector,一旦 trace tree 長得不對,你很難判斷問題出在 instrumentation 邏輯,還是 Collector 設定、網路連線或後端儲存。先用 console exporter 把「span 產生得對不對」單獨隔離確認,再換成 OTLP,才能確定後續問題都跟 instrumentation 無關——這正是第⑧節 API/SDK 分離帶來的好處:換 exporter 只是改一行設定,不必碰 workflow.py。

這一步驗證了文章哪個論點:第②節「API 與 SDK 分離」——uv add 只需要 opentelemetry-api 與 opentelemetry-sdk 兩個套件就能先跑起最小 trace,不需要先決定任何後端廠商,呼應了「應用程式不該被綁死在某個 exporter 上」的設計原則。

預期會踩到的坑:如果忘記同時裝 opentelemetry-sdk,只裝了 opentelemetry-api,程式會在 TracerProvider 那一行直接 import error——這其實是 API/SDK 分離設計的正常副作用:API 套件本身不包含任何具體實作,必須靠 SDK 套件補上,藉此強迫開發者顯式決定「這次要用哪一種 SDK 實作」。

2. 放入最小 workflow

建立前一節的三個檔案。

Day23/DIY/
├─ app/
│  ├─ __init__.py
│  ├─ main.py
│  ├─ telemetry.py
│  └─ workflow.py
└─ pyproject.toml

請保留 demo 的 mock model。

第一輪的目標是測 trace tree,不是測哪個模型回答得比較漂亮。

為什麼刻意不接真實 LLM:第一輪就接上真實模型,一旦 trace tree 長得不對,你無法分辨問題是出在 instrumentation,還是模型本身的隨機性。用 mock model 把「輸出內容」鎖死成固定字串,任何 trace tree 異常都只可能來自你自己的 instrumentation 程式碼,排查範圍縮小到你能掌控的部分。

這一步驗證了文章哪個論點:第③節「span tree 不該只有 llm.call」——你會實際看到 prompt.build、retrieval.search、model.chat、answer.validate 四個獨立 span,而不是把整個 workflow 塞進一個節點。

3. 啟動後送出固定問題

uv run uvicorn app.main:app --reload

另開 terminal,呼叫固定案例。

curl -sS http://127.0.0.1:8000/ask \
  -H 'content-type: application/json' \
  -d '{"question":"公司遠端工作是否需要主管核准?"}'

預期 HTTP response 應含下列欄位。

{
  "request_id": "local-1",
  "answer": "依遠端工作政策,請依主管核准流程辦理。",
  "task_status": "completed",
  "quality_status": "pass"
}

terminal 的 console exporter 應依序印出至少這些 span name。

prompt.build
retrieval.search
model.chat
answer.validate
POST /ask

每個 child span 都應與 root 共用 trace_id。

不同 span 的 span_id 應不同。

如果所有 span 都是獨立 trace,優先檢查是不是在每個 helper 都重新建立 provider,或是否在 span context 外呼叫了函式。

這一步驗證了文章哪個論點:第②節的 W3C traceparent 傳遞機制。這個 demo 雖是單一 process,但 tracer.start_as_current_span() 底層仍靠同一套 context propagation 機制讓 child span 知道自己的 parent 是誰;async task 之間若沒有正確傳遞 context,同樣會看到 span 各自變成獨立 trace——這正是第②節「斷點通常不會有任何錯誤訊息提醒你」的縮影。

預期會踩到的坑:忘記呼叫 configure_tracing(),或把它放在每個 request handler 裡重複呼叫,都會讓 provider 設定跑掉——前者讓所有 span 用預設的 no-op tracer(完全沒輸出),後者可能讓每個 request 重新建立新的 TracerProvider,span 之間的關聯行為變得不穩定。

4. 人工畫出 tree,而不是只找字串

把 terminal 中的輸出整理成這種表。

span parent 必看欄位 預期 outcome
POST /ask 無 request id、workflow version、task status completed
prompt.build root prompt version、question hash ok
retrieval.search root index version、result count ok
model.chat root model route、token count ok
answer.validate root quality、safety、citation ok

若你只能看到欄位卻看不出 parent,這份輸出還不能支援 incident investigation。

為什麼要求人工畫表,而不是直接寫腳本比對:這一步刻意設計成人工作業,讓你在遇到生產環境的複雜 trace 之前,先用眼睛確認過一次「怎樣才算一棵正確的樹」。自動化比對腳本能在 CI 裡守住 regression,但教不了你「看到這種 span 排列方式該懷疑什麼」——這步的價值在於建立直覺,直覺沒辦法靠自動化跳過。

5. 加入一個可預期的 failure fixture

在 validate() 增加一條故意失敗的條件。

grounded = "主管核准" in answer and bool(documents)
if "測試失敗" in answer:
    grounded = False

然後暫時讓 mock model 回傳 測試失敗:沒有引用來源。

預期的不是 HTTP 500。

預期是 root span 維持 technical.status=ok,但 quality.status=fail 與 task.status=failed。

這條 fixture 能驗證 Day 7 的語意失敗沒有被技術成功吞掉。

測完請還原 mock response,避免把測試條件當成正式行為。

這一步驗證了文章哪個論點:整個 DIY 裡最關鍵的一步,直接對應第⑤節的核心主張——技術成功不等於語意成功。你會看到 HTTP status code 仍是 200、technical.status 仍是 ok,但 quality.status 變成 fail、task.status 變成 failed。若直覺是「這應該回傳 500 才對」,正是第⑤節想糾正的直覺:讓程式碼因語意失敗拋出未處理例外,會把本該被結構化記錄的品質問題,偽裝成系統故障,反而讓 on-call 誤判優先順序。

預期會踩到的坑:把 grounded = False 硬寫死忘記還原,之後所有請求都會回報品質失敗,讓驗收清單全部失真——這正是「測完請還原」的提醒不是形式的原因。

6. 寫出禁止欄位測試

最簡單的起點是掃描 console output 或 exporter 前的 span attribute。

不得出現:
- question 的完整原文
- document.text
- authorization header
- email / phone

必須出現:
- prompt.version
- retrieval.index_version
- model.name
- quality.status
- task.status

這不是完整的 DLP 系統。

它是一個能在 code review 與 regression test 中先攔住明顯洩漏的 guardrail。

這一步驗證了文章哪個論點:第⑦節 schema contract 裡「禁止預設寫入」那一欄,以及第⑪節的隱私分級討論。契約寫在 markdown 裡不能保證程式碼真的遵守;只有變成能在 CI 裡自動跑的檢查,才能防止某次重構不小心把 question 原文塞進新加的 attribute。

如果你真的跑了完整這六步,你會發現自己已經不靠 dashboard,僅憑 console 裡幾段 JSON 輸出,就能回答「這次 request 慢在哪、卡在哪個 outcome、有沒有洩漏敏感資料」——這正是本篇開頭「trace 的目標是讓另一位工程師重建判斷順序」的具體體現。

⑬ 驗收清單

完成 DIY 後,逐項勾選。

[ ] 一個 `/ask` request 只形成一個 root trace。
[ ] prompt.build、retrieval.search、model.chat、answer.validate 都是 root 的 child span。
[ ] child span 與 root 共用 trace_id,且各自有不同 span_id。
[ ] root span 記錄 workflow version、technical status、quality status、task status。
[ ] retrieval span 記錄 index version 與 result count,沒有記錄文件全文。
[ ] model span 記錄 provider、model、prompt version、token count,沒有記錄完整 prompt 或 response。
[ ] quality failure 會被記成 quality/task failure,而不是假裝 HTTP 500。
[ ] tool timeout 與 policy refusal 可從不同 span attribute 分辨。
[ ] logs 能以 request_id、trace_id 或 span_id 和 trace 關聯。
[ ] metrics 沒有把 request_id、trace_id、prompt 或 user id 當 label。
[ ] 已明定 retention、sampling 與敏感資料遮罩的責任歸屬。

勾不到的項目不是失敗證明。

它是下一次 schema 修改前應先處理的缺口。

這份清單刻意設計成「在 local demo 上就能全部勾完」的難度,而不是要求接一套完整的生產環境才能驗證。若驗收清單只能在正式環境才跑得完,團隊往往要等到 staging 甚至 production 部署後才發現 schema 設計有問題,那時要改動已牽動更多下游依賴。把驗收往前移到 local,代價是覆蓋不到真正的網路延遲與多服務邊界;但它能在成本最低的階段,先攔下「trace tree 結構對不對」「敏感欄位有沒有洩漏」這兩類最基本也最容易犯錯的問題。

⑭ 常見失敗:看起來有 trace,其實不能用

只有 root span

症狀:每條 trace 只看到 POST /ask。

後果:你知道 API 慢,但不知道慢在哪個 workflow stage。

修正:至少拆出 prompt、retrieval、model、validate;有 tool 就再拆 policy check 和 execute。

將完整 prompt 當作預設 attribute

症狀:debug 很方便,資料庫也很快變成另一份未治理的 prompt archive。

後果:權限、retention、匯出與事故截圖都變成資料外洩面。

這個後果往往在意料之外的地方爆發——事故排查時某人把 trace explorer 截圖貼進共用的 incident channel 求助,截圖裡剛好帶著完整使用者提問內容,而該 channel 的存取權限遠比 trace 後端寬鬆。

修正:預設只留 hash、長度、版本與分類;若真的需要原文,建立明確的 opt-in 與最小權限流程。

把所有非 200 都叫 error,所有 200 都叫 success

症狀:安全拒絕被算 error,無根據答案被算 success。

Cursor 客服機器人事件正是後者的具體案例:機器人編造的政策回覆在系統眼中是一次完美的 200 success,直到用戶大規模取消訂閱才有人發現內容是假的。

後果:alert、SLO 與 release 判斷都被錯誤分類。

一旦這種分類錯誤滲進 SLO 計算,團隊很可能在慶祝一個充滿無根據答案的高可用性數字,或反過來因正常的安全拒絕被誤算進錯誤率,浪費 on-call 的注意力。

修正:讓 technical、workflow、quality、safety、task 狀態分開。

用 request id 當 metric label

症狀:單筆查詢很容易,Prometheus 記憶體卻越吃越多。

後果:高 cardinality 讓真正需要的聚合查詢變慢或變貴。

Prometheus 這類時間序列資料庫對每一組獨特的 label 組合都要開一條新時間序列,request id 這種天生獨一無二的值一旦當 label,時間序列數量會隨流量線性膨脹,拖累查詢與儲存效能——這個錯誤特別容易在 debug 壓力下發生:工程師想快速篩出某個 request 的所有 metric,直覺反應是把 request_id 加進 label,卻沒意識到基數會隨流量無限成長,最終拖垮整個 metric 後端。

修正:把唯一 id 留給 trace/log,把可聚合的低 cardinality outcome 留給 metric。

讓 vendor 欄位名直接滲進商業邏輯

症狀:程式每一層都知道某個 tracing product 的 run、generation 或 observation 物件。

後果:換後端、雙寫或調整 schema 時,業務程式也被迫跟著改。

這也是第⑦節花篇幅解釋 GenAI semantic conventions 仍在「Development」狀態的原因——商業邏輯若直接依賴一套還在變動的規範命名,每次調整都會變成跨團隊的協調成本。

修正:先維護內部 AITraceContext 與 contract,在 adapter 層映射到 OpenTelemetry 或特定平台。

把 sampling 一刀切成全量或全不留

症狀:要嘛所有 trace 都全量保留原文方便 debug,要嘛乾脆什麼細節都不留只求安全。

後果:全量保留很快撞上第⑪節的隱私與成本問題;全不保留則讓第⑥節的多輪 context drift 這類問題完全無法回溯,事故發生時只能兩手一攤。

修正:依 outcome 分層 sampling——成功路徑低比例,technical error / quality fail / safety refusal 較高比例,並讓抽樣視窗的比例趨勢、而不是單一樣本,決定要不要升級處理。

agent 迴圈把整段執行塞進一個 span

症狀:agent.loop 只有一個 span,記著總耗時與總輪數,看不出第幾輪開始出問題。

後果:發現 agent 卡在迴圈裡重複呼叫同一個 tool 時,只能盲猜是第幾輪開始跑偏,或乾脆整段重播才找得到。

前面提到 11 天燒掉 4.7 萬美元的多 agent 迴圈,正是這類問題被拖到最後才發現的極端案例——若每一輪都有獨立 span 與 finish_reason,理論上第一天就能抓到,不必等帳單寄來。

修正:每個 agent.turn 都是獨立 span,並確實記錄該輪的 finish_reason,讓你能直接鎖定「context 從哪一輪開始被誤解」。

所有 span 都標成 INTERNAL,service map 因此斷裂

症狀:Grafana Tempo 或其他後端的服務地圖上,看不出這個 API 真的呼叫了外部 retriever service 或外部 model provider,因為所有 span 都用同一種 kind。

後果:想知道「這個延遲是不是外部依賴造成的」,得逐條打開 span 讀 attribute,service map 這種一眼看出依賴關係的工具形同虛設。

修正:對外呼叫標 CLIENT,對外接收標 SERVER,process 內部運算才標 INTERNAL,讓後端能正確配對跨服務的呼叫關係。

⑮ 本文結論

好的 trace 讓人還原系統,不是還原使用者私密內容。

它應該能將一個 request 的 prompt 版本、資料來源版本、model route、tool 決策與品質結果串成可追查的證據鏈。

它不能單獨證明答案正確,所以仍要和 evaluation、feedback 與 release gate 一起工作。

回顧今天走過的路徑:從 Dapper 十六年前定下的三個前提,到 W3C traceparent 讓 context 能穿越服務邊界,到把 outcome 拆成 technical / workflow / quality / safety / task 五個獨立欄位,再到 schema contract 把「什麼該記、什麼禁止記」寫成可被 code review 與測試守住的規則——這些設計選擇的共同目標只有一個:讓值班的人在最混亂的時刻,仍能沿著一條 trace 重建系統當時做了什麼判斷。11 天燒掉 4.7 萬美元的迴圈、看似合格卻在第十輪後悄悄跑偏的 agent,這些案例的共同教訓不是「LLM 系統比較容易出事」,而是「傳統只看 HTTP 狀態碼與例外堆疊的監控方式,在語意失敗面前完全失靈」。trace 補的正是這一塊視野。

下一篇會把今天定義的訊號排進 dashboard。

目標不是做一面資訊牆,而是讓值班的人從使用者影響一路鑽到同一條 trace。

回到今天最開頭那個問題:一條 trace 能不能讓另一位工程師重建判斷順序。如果你已經走完第⑫節的六個步驟,答案應該已經不是抽象的承諾,而是你親眼在 terminal 裡看過的那五個 span,以及它們共用的那一個 trace_id。剩下的工作,是把這套結構從 local demo 搬進真正有流量、有多個服務邊界、有真實使用者的系統——這正是 Day 24 到 Day 32 要陸續處理的事。

下一篇:Day 24|Dashboard Design

下一篇會接著處理「Dashboard Design:首頁不是監控倉庫」。

延伸閱讀


這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 23(上)|LLM Observability:一條 Trace 要能還原工作流
下一篇
Day 24(上)|Dashboard Design:首頁不是監控倉庫
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言